@tuturuuu/ui 0.19.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/biome.json +1 -1
  3. package/package.json +62 -50
  4. package/src/components/ui/badge.tsx +2 -0
  5. package/src/components/ui/button.tsx +2 -0
  6. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +48 -4
  7. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +22 -0
  8. package/src/components/ui/custom/animated-slot-text.tsx +20 -0
  9. package/src/components/ui/custom/common-footer.tsx +262 -237
  10. package/src/components/ui/custom/language-dropdown-item.tsx +3 -10
  11. package/src/components/ui/custom/language-toggle.test.tsx +30 -0
  12. package/src/components/ui/custom/language-toggle.tsx +11 -10
  13. package/src/components/ui/custom/locale-preference.ts +32 -0
  14. package/src/components/ui/custom/settings/appearance-settings.tsx +5 -13
  15. package/src/components/ui/custom/settings-dialog-shell.tsx +40 -9
  16. package/src/components/ui/custom/structure.tsx +12 -0
  17. package/src/components/ui/custom/system-language-dropdown-item.tsx +3 -6
  18. package/src/components/ui/custom/workspace-access/adapters.test.ts +10 -1
  19. package/src/components/ui/custom/workspace-access/adapters.ts +36 -4
  20. package/src/components/ui/custom/workspace-access/member-filter-utils.test.ts +14 -0
  21. package/src/components/ui/custom/workspace-access/member-filter-utils.ts +8 -0
  22. package/src/components/ui/custom/workspace-access/types.ts +20 -0
  23. package/src/components/ui/custom/workspace-access/workspace-access-context.test.tsx +111 -0
  24. package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +15 -5
  25. package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.tsx +155 -48
  26. package/src/components/ui/custom/workspace-access/workspace-access-member-profile-dialog.tsx +92 -0
  27. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +171 -76
  28. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +11 -2
  29. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +11 -11
  30. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +180 -22
  31. package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +29 -15
  32. package/src/components/ui/custom/workspace-access/workspace-access-permission-checklist.tsx +56 -10
  33. package/src/components/ui/custom/workspace-access/workspace-access-permission-preview.test.ts +33 -0
  34. package/src/components/ui/custom/workspace-access/workspace-access-permission-preview.tsx +24 -1
  35. package/src/components/ui/custom/workspace-access/workspace-access-responsive.test.ts +64 -0
  36. package/src/components/ui/custom/workspace-access/workspace-access-role-editor-dialog.tsx +28 -16
  37. package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +35 -11
  38. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +23 -11
  39. package/src/components/ui/custom/workspace-select-helpers.ts +5 -3
  40. package/src/components/ui/custom/workspace-select.tsx +8 -2
  41. package/src/components/ui/finance/shared/charts/monthly-total-chart-client.tsx +1 -1
  42. package/src/components/ui/finance/shared/charts/monthly-total-chart.tsx +1 -1
  43. package/src/components/ui/storefront/cart-summary.tsx +12 -2
  44. package/src/components/ui/storefront/storefront-surface.test.tsx +21 -0
  45. package/src/components/ui/storefront/storefront-surface.tsx +6 -0
  46. package/src/components/ui/text-editor/__tests__/collaboration-binding.test.tsx +161 -0
  47. package/src/components/ui/text-editor/editor.tsx +267 -235
  48. package/src/globals.css +166 -0
  49. package/src/hooks/__tests__/use-workspace-identity-mutation.test.tsx +181 -0
  50. package/src/hooks/use-workspace-identity-mutation.ts +136 -0
  51. package/vendor/xlsx/LICENSE +201 -0
  52. package/vendor/xlsx/types/index.d.ts +1065 -0
  53. package/vendor/xlsx/xlsx.js +28103 -0
  54. package/vendor/xlsx/xlsx.mjs +28228 -0
  55. package/vendor/xlsx-0.20.3.tgz +0 -0
@@ -1,8 +1,70 @@
1
1
  import { getLocalInternalAppUrl } from '@tuturuuu/utils/internal-domains';
2
2
  import Link from 'next/link';
3
- import { Separator } from '../separator';
3
+ import type { ReactNode } from 'react';
4
4
  import { TuturuuLogo } from './tuturuuu-logo';
5
5
 
6
+ interface FooterLink {
7
+ label: ReactNode;
8
+ href: string;
9
+ external?: boolean;
10
+ }
11
+
12
+ function FooterColumn({
13
+ title,
14
+ links,
15
+ }: {
16
+ title: ReactNode;
17
+ links: FooterLink[];
18
+ }) {
19
+ return (
20
+ <div className="grid content-start gap-3">
21
+ <div className="flex items-center gap-2">
22
+ <span className="font-mono-ui text-[0.6rem] text-foreground/35 uppercase tracking-[0.2em]">
23
+ {title}
24
+ </span>
25
+ <span
26
+ aria-hidden
27
+ className="h-px flex-1 bg-gradient-to-r from-foreground/12 to-transparent"
28
+ />
29
+ </div>
30
+ {links.map((link) => (
31
+ <Link
32
+ className="w-fit text-foreground/60 text-sm transition-colors duration-200 hover:text-foreground"
33
+ href={link.href}
34
+ key={link.href}
35
+ {...(link.external
36
+ ? { target: '_blank', rel: 'noopener noreferrer' }
37
+ : {})}
38
+ >
39
+ {link.label}
40
+ </Link>
41
+ ))}
42
+ </div>
43
+ );
44
+ }
45
+
46
+ function SocialLink({
47
+ href,
48
+ label,
49
+ children,
50
+ }: {
51
+ href: string;
52
+ label: string;
53
+ children: ReactNode;
54
+ }) {
55
+ return (
56
+ <Link
57
+ aria-label={label}
58
+ className="flex h-9 w-9 items-center justify-center rounded-lg border border-foreground/10 fill-foreground/50 transition-colors duration-300 hover:border-foreground/25 hover:fill-foreground"
59
+ href={href}
60
+ rel="noopener noreferrer"
61
+ target="_blank"
62
+ >
63
+ {children}
64
+ </Link>
65
+ );
66
+ }
67
+
6
68
  export function CommonFooter({
7
69
  t,
8
70
  devMode,
@@ -15,266 +77,229 @@ export function CommonFooter({
15
77
  const TUTURUUU_URL = devMode
16
78
  ? getLocalInternalAppUrl('platform', 'http://localhost:7803')
17
79
  : 'https://tuturuuu.com';
80
+ // The standalone qr.tuturuuu.com host is retired; the generator now lives
81
+ // under the tools app.
18
82
  const QR_URL = devMode
19
- ? getLocalInternalAppUrl('qr', 'http://localhost:7819')
20
- : 'https://qr.tuturuuu.com';
83
+ ? `${getLocalInternalAppUrl('tools', 'http://localhost:7825')}/qr`
84
+ : 'https://tools.tuturuuu.com/qr';
21
85
 
22
- return (
23
- <div className="w-full text-center">
24
- {/* {pathname.startsWith('/contact') || (
25
- <>
26
- <Separator className="bg-foreground/5 mb-8" />
27
- <div className="flex flex-col items-center">
28
- <Slogan />
29
- </div>
30
- </>
31
- )} */}
86
+ const columns: Array<{ title: ReactNode; links: FooterLink[] }> = [
87
+ {
88
+ title: t('common.resources'),
89
+ links: [
90
+ { label: t('common.blog'), href: `${TUTURUUU_URL}/blog` },
91
+ {
92
+ label: t('common.meet-together'),
93
+ href: `${TUTURUUU_URL}/meet-together`,
94
+ },
95
+ { label: t('common.qr_generator'), href: QR_URL },
96
+ {
97
+ label: t('common.facebook_mockup'),
98
+ href: `${TUTURUUU_URL}/facebook-mockup`,
99
+ },
100
+ { label: t('common.branding'), href: `${TUTURUUU_URL}/branding` },
101
+ { label: t('common.ui'), href: `${TUTURUUU_URL}/ui` },
102
+ ],
103
+ },
104
+ {
105
+ title: t('common.company'),
106
+ links: [
107
+ { label: t('common.about'), href: `${TUTURUUU_URL}/about` },
108
+ { label: t('common.partners'), href: `${TUTURUUU_URL}/partners` },
109
+ { label: t('common.careers'), href: `${TUTURUUU_URL}/careers` },
110
+ { label: t('common.contact'), href: `${TUTURUUU_URL}/contact` },
111
+ { label: t('common.pricing'), href: `${TUTURUUU_URL}/pricing` },
112
+ ],
113
+ },
114
+ {
115
+ title: t('common.legal'),
116
+ links: [
117
+ { label: t('common.security'), href: `${TUTURUUU_URL}/security` },
118
+ { label: t('common.terms'), href: `${TUTURUUU_URL}/terms` },
119
+ { label: t('common.privacy'), href: `${TUTURUUU_URL}/privacy` },
120
+ {
121
+ label: t('common.community-guidelines'),
122
+ href: `${TUTURUUU_URL}/community-guidelines`,
123
+ },
124
+ {
125
+ label: t('common.acceptable-use'),
126
+ href: `${TUTURUUU_URL}/acceptable-use`,
127
+ },
128
+ ],
129
+ },
130
+ {
131
+ title: t('common.developers'),
132
+ links: [
133
+ {
134
+ label: t('common.documentation'),
135
+ href: 'https://docs.tuturuuu.com',
136
+ external: true,
137
+ },
138
+ {
139
+ label: t('common.open-source'),
140
+ href: 'https://github.com/tutur3u/platform',
141
+ external: true,
142
+ },
143
+ {
144
+ label: t('common.changelog'),
145
+ href: `${TUTURUUU_URL}/changelog`,
146
+ },
147
+ ],
148
+ },
149
+ ];
32
150
 
33
- <Separator className="my-8 bg-foreground/5" />
34
-
35
- <div className="flex flex-col flex-wrap items-center justify-between gap-4 px-4 md:gap-8 md:px-32 lg:flex-row lg:gap-16 xl:px-64">
36
- <div className="flex flex-col items-center justify-center gap-4">
37
- <Link
38
- href={TUTURUUU_URL}
39
- className="flex items-center gap-4 transition"
40
- aria-label="Tuturuuu"
41
- >
42
- <TuturuuLogo
43
- width={64}
44
- height={64}
45
- alt="logo"
46
- src={logoSrc}
47
- className="h-12 w-12"
48
- />
49
- <div className="font-semibold text-4xl">Tuturuuu</div>
50
- </Link>
151
+ return (
152
+ <footer className="relative mt-16 w-full border-foreground/10 border-t">
153
+ {/* Brand wash */}
154
+ <div
155
+ aria-hidden
156
+ className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-dynamic-purple/40 to-transparent"
157
+ />
51
158
 
52
- <div className="flex gap-8">
159
+ <div className="mx-auto w-full max-w-7xl px-6 py-14 lg:px-8">
160
+ <div className="grid gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]">
161
+ {/* Brand */}
162
+ <div className="grid content-start gap-5">
53
163
  <Link
54
- target="_blank"
55
- href="https://www.facebook.com/tuturuuu"
56
- className="fill-foreground/50 transition duration-300 hover:fill-foreground"
57
- aria-label="Facebook"
164
+ aria-label="Tuturuuu"
165
+ className="flex w-fit items-center gap-3 transition-opacity hover:opacity-80"
166
+ href={TUTURUUU_URL}
58
167
  >
59
- <svg
60
- xmlns="http://www.w3.org/2000/svg"
61
- viewBox="0 0 512 512"
62
- className="w-6"
63
- >
64
- <title>Facebook</title>
65
- <path d="M504 256C504 119 393 8 256 8S8 119 8 256c0 123.78 90.69 226.38 209.25 245V327.69h-63V256h63v-54.64c0-62.15 37-96.48 93.67-96.48 27.14 0 55.52 4.84 55.52 4.84v61h-31.28c-30.8 0-40.41 19.12-40.41 38.73V256h68.78l-11 71.69h-57.78V501C413.31 482.38 504 379.78 504 256z" />
66
- </svg>
168
+ <TuturuuLogo
169
+ alt="logo"
170
+ className="h-9 w-9"
171
+ height={48}
172
+ src={logoSrc}
173
+ width={48}
174
+ />
175
+ <span className="font-display font-semibold text-2xl tracking-[-0.03em]">
176
+ Tuturuuu
177
+ </span>
67
178
  </Link>
68
179
 
69
- <Link
70
- target="_blank"
71
- href="https://www.instagram.com/tutu.ruuu/"
72
- className="fill-foreground/50 transition duration-300 hover:fill-foreground"
73
- aria-label="Instagram"
74
- >
75
- <svg
76
- viewBox="0 0 32 32"
77
- xmlns="http://www.w3.org/2000/svg"
78
- className="w-6 scale-150"
79
- >
80
- <title>Instagram</title>
81
- <path d="M20.445 5h-8.891A6.559 6.559 0 0 0 5 11.554v8.891A6.559 6.559 0 0 0 11.554 27h8.891a6.56 6.56 0 0 0 6.554-6.555v-8.891A6.557 6.557 0 0 0 20.445 5zm4.342 15.445a4.343 4.343 0 0 1-4.342 4.342h-8.891a4.341 4.341 0 0 1-4.341-4.342v-8.891a4.34 4.34 0 0 1 4.341-4.341h8.891a4.342 4.342 0 0 1 4.341 4.341l.001 8.891z" />
82
- <path d="M16 10.312c-3.138 0-5.688 2.551-5.688 5.688s2.551 5.688 5.688 5.688 5.688-2.551 5.688-5.688-2.55-5.688-5.688-5.688zm0 9.163a3.475 3.475 0 1 1-.001-6.95 3.475 3.475 0 0 1 .001 6.95zM21.7 8.991a1.363 1.363 0 1 1-1.364 1.364c0-.752.51-1.364 1.364-1.364z" />
83
- </svg>
84
- </Link>
180
+ <p className="max-w-xs text-balance text-foreground/60 text-sm leading-relaxed">
181
+ {t('common.footer_tagline')}
182
+ </p>
85
183
 
86
- <Link
87
- target="_blank"
88
- href="https://x.com/tutur3u"
89
- className="fill-foreground/50 transition duration-300 hover:fill-foreground"
90
- aria-label="X (formerly Twitter)"
91
- >
92
- <svg
93
- xmlns="http://www.w3.org/2000/svg"
94
- viewBox="0 0 42 42"
95
- className="w-6"
184
+ <div className="flex flex-wrap gap-2">
185
+ <SocialLink
186
+ href="https://www.facebook.com/tuturuuu"
187
+ label="Facebook"
96
188
  >
97
- <title>X (formerly Twitter)</title>
98
- <polygon points="41,6 9.929,42 6.215,42 37.287,6" />
99
- <polygon
100
- className="fill-background"
101
- fillRule="evenodd"
102
- points="31.143,41 7.82,7 16.777,7 40.1,41"
103
- clipRule="evenodd"
104
- />
105
- <path d="M15.724,9l20.578,30h-4.106L11.618,9H15.724 M17.304,6H5.922l24.694,36h11.382L17.304,6L17.304,6z" />
106
- </svg>
107
- </Link>
189
+ <svg
190
+ className="w-4"
191
+ viewBox="0 0 512 512"
192
+ xmlns="http://www.w3.org/2000/svg"
193
+ >
194
+ <title>Facebook</title>
195
+ <path d="M504 256C504 119 393 8 256 8S8 119 8 256c0 123.78 90.69 226.38 209.25 245V327.69h-63V256h63v-54.64c0-62.15 37-96.48 93.67-96.48 27.14 0 55.52 4.84 55.52 4.84v61h-31.28c-30.8 0-40.41 19.12-40.41 38.73V256h68.78l-11 71.69h-57.78V501C413.31 482.38 504 379.78 504 256z" />
196
+ </svg>
197
+ </SocialLink>
108
198
 
109
- <Link
110
- target="_blank"
111
- href="https://github.com/tutur3u"
112
- className="fill-foreground/50 transition duration-300 hover:fill-foreground"
113
- aria-label="Github"
114
- >
115
- <svg
116
- xmlns="http://www.w3.org/2000/svg"
117
- viewBox="0 0 512 512"
118
- className="w-6"
199
+ <SocialLink
200
+ href="https://www.instagram.com/tutu.ruuu/"
201
+ label="Instagram"
119
202
  >
120
- <title>GitHub</title>
121
- <path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
122
- </svg>
123
- </Link>
203
+ <svg
204
+ className="w-5"
205
+ viewBox="0 0 32 32"
206
+ xmlns="http://www.w3.org/2000/svg"
207
+ >
208
+ <title>Instagram</title>
209
+ <path d="M20.445 5h-8.891A6.559 6.559 0 0 0 5 11.554v8.891A6.559 6.559 0 0 0 11.554 27h8.891a6.56 6.56 0 0 0 6.554-6.555v-8.891A6.557 6.557 0 0 0 20.445 5zm4.342 15.445a4.343 4.343 0 0 1-4.342 4.342h-8.891a4.341 4.341 0 0 1-4.341-4.342v-8.891a4.34 4.34 0 0 1 4.341-4.341h8.891a4.342 4.342 0 0 1 4.341 4.341l.001 8.891z" />
210
+ <path d="M16 10.312c-3.138 0-5.688 2.551-5.688 5.688s2.551 5.688 5.688 5.688 5.688-2.551 5.688-5.688-2.55-5.688-5.688-5.688zm0 9.163a3.475 3.475 0 1 1-.001-6.95 3.475 3.475 0 0 1 .001 6.95zM21.7 8.991a1.363 1.363 0 1 1-1.364 1.364c0-.752.51-1.364 1.364-1.364z" />
211
+ </svg>
212
+ </SocialLink>
124
213
 
125
- <Link
126
- target="_blank"
127
- href="https://www.linkedin.com/company/tuturuuu/"
128
- className="fill-foreground/50 transition duration-300 hover:fill-foreground"
129
- aria-label="LinkedIn"
130
- >
131
- <svg
132
- xmlns="http://www.w3.org/2000/svg"
133
- viewBox="0 0 512 512"
134
- className="w-6"
214
+ <SocialLink
215
+ href="https://x.com/tutur3u"
216
+ label="X (formerly Twitter)"
135
217
  >
136
- <title>LinkedIn</title>
137
- <path d="M416 32H31.9C14.3 32 0 46.5 0 64.3v383.4C0 465.5 14.3 480 31.9 480H416c17.6 0 32-14.5 32-32.3V64.3c0-17.8-14.4-32.3-32-32.3zM135.4 416H69V202.2h66.5V416zm-33.2-243c-21.3 0-38.5-17.3-38.5-38.5S80.9 96 102.2 96c21.2 0 38.5 17.3 38.5 38.5 0 21.3-17.2 38.5-38.5 38.5zm282.1 243h-66.4V312c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9V416h-66.4V202.2h63.7v29.2h.9c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9V416z" />
138
- </svg>
139
- </Link>
140
- </div>
141
- </div>
218
+ <svg
219
+ className="w-4"
220
+ viewBox="0 0 42 42"
221
+ xmlns="http://www.w3.org/2000/svg"
222
+ >
223
+ <title>X (formerly Twitter)</title>
224
+ <polygon points="41,6 9.929,42 6.215,42 37.287,6" />
225
+ <polygon
226
+ className="fill-background"
227
+ clipRule="evenodd"
228
+ fillRule="evenodd"
229
+ points="31.143,41 7.82,7 16.777,7 40.1,41"
230
+ />
231
+ <path d="M15.724,9l20.578,30h-4.106L11.618,9H15.724 M17.304,6H5.922l24.694,36h11.382L17.304,6L17.304,6z" />
232
+ </svg>
233
+ </SocialLink>
142
234
 
143
- <div className="flex flex-none flex-col flex-wrap items-center gap-4 md:flex-row md:items-start md:gap-16 lg:gap-32">
144
- <div className="grid gap-1 md:items-start">
145
- <div className="mb-2 font-bold text-lg md:w-fit md:text-xl">
146
- {t('common.resources')}
147
- </div>
148
- <Link
149
- href={`${TUTURUUU_URL}/blog`}
150
- className="text-foreground/80 text-sm hover:text-foreground hover:underline md:w-fit"
151
- >
152
- {t('common.blog')}
153
- </Link>
154
- <Link
155
- href={`${TUTURUUU_URL}/meet-together`}
156
- className="text-foreground/80 text-sm hover:text-foreground hover:underline md:w-fit"
157
- >
158
- {t('common.meet-together')}
159
- </Link>
160
- <Link
161
- href={QR_URL}
162
- className="text-foreground/80 text-sm hover:text-foreground hover:underline md:w-fit"
163
- >
164
- {t('common.qr_generator')}
165
- </Link>
166
- <Link
167
- href={`${TUTURUUU_URL}/facebook-mockup`}
168
- className="text-foreground/80 text-sm hover:text-foreground hover:underline md:w-fit"
169
- >
170
- {t('common.facebook_mockup')}
171
- </Link>
172
- <Link
173
- href={`${TUTURUUU_URL}/branding`}
174
- className="text-foreground/80 text-sm hover:text-foreground hover:underline md:w-fit"
175
- >
176
- {t('common.branding')}
177
- </Link>
178
- <Link
179
- href={`${TUTURUUU_URL}/ui`}
180
- className="text-foreground/80 text-sm hover:text-foreground hover:underline md:w-fit"
181
- >
182
- {t('common.ui')}
183
- </Link>
184
- </div>
235
+ <SocialLink href="https://github.com/tutur3u" label="Github">
236
+ <svg
237
+ className="w-4"
238
+ viewBox="0 0 512 512"
239
+ xmlns="http://www.w3.org/2000/svg"
240
+ >
241
+ <title>GitHub</title>
242
+ <path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
243
+ </svg>
244
+ </SocialLink>
185
245
 
186
- <div className="grid gap-1 md:items-start">
187
- <div className="mb-2 font-bold text-lg md:w-fit md:text-xl">
188
- {t('common.company')}
246
+ <SocialLink
247
+ href="https://www.linkedin.com/company/tuturuuu/"
248
+ label="LinkedIn"
249
+ >
250
+ <svg
251
+ className="w-4"
252
+ viewBox="0 0 512 512"
253
+ xmlns="http://www.w3.org/2000/svg"
254
+ >
255
+ <title>LinkedIn</title>
256
+ <path d="M416 32H31.9C14.3 32 0 46.5 0 64.3v383.4C0 465.5 14.3 480 31.9 480H416c17.6 0 32-14.5 32-32.3V64.3c0-17.8-14.4-32.3-32-32.3zM135.4 416H69V202.2h66.5V416zm-33.2-243c-21.3 0-38.5-17.3-38.5-38.5S80.9 96 102.2 96c21.2 0 38.5 17.3 38.5 38.5 0 21.3-17.2 38.5-38.5 38.5zm282.1 243h-66.4V312c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9V416h-66.4V202.2h63.7v29.2h.9c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9V416z" />
257
+ </svg>
258
+ </SocialLink>
189
259
  </div>
190
- <Link
191
- href={`${TUTURUUU_URL}/about`}
192
- className="text-foreground/80 text-sm hover:text-foreground hover:underline md:w-fit"
193
- >
194
- {t('common.about')}
195
- </Link>
196
- <Link
197
- href={`${TUTURUUU_URL}/partners`}
198
- className="text-foreground/80 text-sm hover:text-foreground hover:underline md:w-fit"
199
- >
200
- {t('common.partners')}
201
- </Link>
202
- <Link
203
- href={`${TUTURUUU_URL}/contact`}
204
- className="text-foreground/80 text-sm hover:text-foreground hover:underline md:w-fit"
205
- >
206
- {t('common.contact')}
207
- </Link>
208
- <Link
209
- href={`${TUTURUUU_URL}/pricing`}
210
- className="text-foreground/80 text-sm hover:text-foreground hover:underline md:w-fit"
211
- >
212
- {t('common.pricing')}
213
- </Link>
214
260
  </div>
215
261
 
216
- <div className="grid gap-1 md:items-start">
217
- <div className="mb-2 font-bold text-lg md:w-fit md:text-xl">
218
- {t('common.legal')}
219
- </div>
220
- <Link
221
- href={`${TUTURUUU_URL}/security`}
222
- className="text-foreground/80 text-sm hover:text-foreground hover:underline md:w-fit"
223
- >
224
- {t('common.security')}
225
- </Link>
226
- <Link
227
- href={`${TUTURUUU_URL}/terms`}
228
- className="text-foreground/80 text-sm hover:text-foreground hover:underline md:w-fit"
229
- >
230
- {t('common.terms')}
231
- </Link>
232
- <Link
233
- href={`${TUTURUUU_URL}/privacy`}
234
- className="text-foreground/80 text-sm hover:text-foreground hover:underline md:w-fit"
235
- >
236
- {t('common.privacy')}
237
- </Link>
238
- <Link
239
- href={`${TUTURUUU_URL}/community-guidelines`}
240
- className="text-foreground/80 text-sm hover:text-foreground hover:underline md:w-fit"
241
- >
242
- {t('common.community-guidelines')}
243
- </Link>
244
- <Link
245
- href={`${TUTURUUU_URL}/acceptable-use`}
246
- className="text-foreground/80 text-sm hover:text-foreground hover:underline md:w-fit"
247
- >
248
- {t('common.acceptable-use')}
249
- </Link>
262
+ {/* Link columns */}
263
+ <div className="grid grid-cols-2 gap-8 sm:grid-cols-4">
264
+ {columns.map((column) => (
265
+ <FooterColumn
266
+ key={String(column.title)}
267
+ links={column.links}
268
+ title={column.title}
269
+ />
270
+ ))}
250
271
  </div>
272
+ </div>
251
273
 
252
- <div className="grid gap-1 md:items-start">
253
- <div className="mb-2 font-bold text-lg md:w-fit md:text-xl">
254
- {t('common.developers')}
255
- </div>
256
- <Link
257
- href="https://docs.tuturuuu.com"
258
- target="_blank"
259
- className="text-foreground/80 text-sm hover:text-foreground hover:underline md:w-fit"
260
- >
261
- {t('common.documentation')}
262
- </Link>
263
- <Link
264
- href="https://github.com/tutur3u/platform"
265
- target="_blank"
266
- className="text-foreground/80 text-sm hover:text-foreground hover:underline md:w-fit"
267
- >
268
- {t('common.open-source')}
269
- </Link>
270
- </div>
274
+ {/* Bottom bar */}
275
+ <div className="mt-14 flex flex-col items-center justify-between gap-4 border-foreground/10 border-t pt-8 sm:flex-row">
276
+ <p className="text-balance text-center font-mono-ui text-[0.65rem] text-foreground/35 uppercase tracking-[0.12em] sm:text-left">
277
+ {t('common.copyright')}
278
+ </p>
279
+ <Link
280
+ className="inline-flex items-center gap-2 rounded-full border border-foreground/10 px-3 py-1.5 font-mono-ui text-[0.62rem] text-foreground/50 uppercase tracking-[0.16em] transition-colors hover:border-foreground/25 hover:text-foreground"
281
+ href="https://github.com/tutur3u/platform"
282
+ rel="noopener noreferrer"
283
+ target="_blank"
284
+ >
285
+ <span className="h-1.5 w-1.5 rounded-full bg-dynamic-green" />
286
+ {t('common.open-source')}
287
+ </Link>
271
288
  </div>
272
289
  </div>
273
290
 
274
- <Separator className="mt-8 bg-foreground/5" />
275
- <div className="text-balance p-4 text-center text-sm opacity-80 md:px-32 xl:px-64">
276
- {t('common.copyright')}
291
+ {/* Wordmark finale — the page signs off at full width, clipped by the
292
+ viewport edge so it reads as a mark rather than a heading. */}
293
+ <div
294
+ aria-hidden
295
+ className="relative select-none overflow-hidden px-6 pb-2 lg:px-8"
296
+ >
297
+ <div className="mx-auto max-w-7xl">
298
+ <div className="bg-[linear-gradient(180deg,color-mix(in_oklab,var(--foreground)_11%,transparent),transparent)] bg-clip-text text-center font-display font-extrabold text-[18vw] text-transparent leading-[0.78] tracking-[-0.055em]">
299
+ Tuturuuu
300
+ </div>
301
+ </div>
277
302
  </div>
278
- </div>
303
+ </footer>
279
304
  );
280
305
  }
@@ -3,6 +3,7 @@
3
3
  import { Check, Hexagon } from '@tuturuuu/icons';
4
4
  import { useRouter } from 'next/navigation';
5
5
  import { DropdownMenuItem } from '../dropdown-menu';
6
+ import { persistLocalePreference } from './locale-preference';
6
7
 
7
8
  interface Props {
8
9
  label: string;
@@ -26,16 +27,8 @@ export function LanguageDropdownItem({
26
27
  return;
27
28
  }
28
29
 
29
- // Default: API-based locale change (web pattern)
30
- const res = await fetch('/api/v1/infrastructure/languages', {
31
- method: 'POST',
32
- body: JSON.stringify({ locale }),
33
- headers: {
34
- 'Content-Type': 'application/json',
35
- },
36
- });
37
-
38
- if (res.ok) router.refresh();
30
+ persistLocalePreference(locale);
31
+ router.refresh();
39
32
  };
40
33
 
41
34
  return (
@@ -0,0 +1,30 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { LanguageToggle } from './language-toggle';
4
+
5
+ const router = vi.hoisted(() => ({ refresh: vi.fn() }));
6
+
7
+ vi.mock('next/navigation', () => ({
8
+ useRouter: () => router,
9
+ }));
10
+
11
+ describe('LanguageToggle', () => {
12
+ beforeEach(() => {
13
+ router.refresh.mockReset();
14
+ vi.restoreAllMocks();
15
+ });
16
+
17
+ it('refreshes after persisting the next locale without an app API route', () => {
18
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
19
+
20
+ render(<LanguageToggle currentLocale="en" forceDisplay />);
21
+ fireEvent.click(screen.getByRole('button', { name: 'enToggle language' }));
22
+
23
+ expect(router.refresh).toHaveBeenCalledTimes(1);
24
+ expect(document.cookie).toContain('NEXT_LOCALE=vi');
25
+ expect(fetchSpy).not.toHaveBeenCalled();
26
+ expect(
27
+ screen.getByRole('button', { name: 'enToggle language' })
28
+ ).toBeEnabled();
29
+ });
30
+ });
@@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation';
4
4
  import { useState } from 'react';
5
5
  import { Button } from '../button';
6
6
  import { LoadingIndicator } from './loading-indicator';
7
+ import { persistLocalePreference } from './locale-preference';
7
8
 
8
9
  export function LanguageToggle({
9
10
  forceDisplay = false,
@@ -15,18 +16,17 @@ export function LanguageToggle({
15
16
  const [loading, setLoading] = useState(false);
16
17
  const router = useRouter();
17
18
 
18
- const updateLocale = async () => {
19
+ const updateLocale = () => {
19
20
  setLoading(true);
20
21
 
21
- const res = await fetch('/api/v1/infrastructure/languages', {
22
- method: 'POST',
23
- body: JSON.stringify({ locale: currentLocale === 'en' ? 'vi' : 'en' }),
24
- headers: {
25
- 'Content-Type': 'application/json',
26
- },
27
- });
28
-
29
- if (res.ok) router.refresh();
22
+ try {
23
+ persistLocalePreference(currentLocale === 'en' ? 'vi' : 'en');
24
+ router.refresh();
25
+ } catch (error) {
26
+ console.error('Failed to update locale', error);
27
+ } finally {
28
+ setLoading(false);
29
+ }
30
30
  };
31
31
 
32
32
  return (
@@ -34,6 +34,7 @@ export function LanguageToggle({
34
34
  variant="outline"
35
35
  size="icon"
36
36
  onClick={updateLocale}
37
+ disabled={loading}
37
38
  className={forceDisplay ? 'flex-none' : 'hidden flex-none md:flex'}
38
39
  >
39
40
  {loading ? <LoadingIndicator /> : currentLocale}