@mohasinac/appkit 4.42.5 → 4.42.6
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.
- package/dist/features/addresses/repository/addresses.repository.d.ts +13 -0
- package/dist/features/addresses/repository/addresses.repository.js +13 -0
- package/dist/features/admin/components/AdminBundleEditorView.js +41 -2
- package/dist/features/categories/schemas/bundle-form.d.ts +5 -5
- package/dist/features/categories/schemas/bundle-form.js +33 -4
- package/dist/features/layout/NavbarLayout.js +23 -2
- package/dist/features/media/messages/en.json +67 -63
- package/package.json +1 -1
|
@@ -19,6 +19,19 @@ export declare class AddressesRepository extends BaseRepository<AddressDocument>
|
|
|
19
19
|
createWithId(id: string, data: Partial<AddressDocument>): Promise<AddressDocument>;
|
|
20
20
|
update(id: string, data: Partial<AddressDocument>): Promise<AddressDocument>;
|
|
21
21
|
listByOwner(ownerType: AddressOwnerType, ownerId: string): Promise<AddressDocument[]>;
|
|
22
|
+
/**
|
|
23
|
+
* 🛑 Requires the composite index `(ownerType ASC, createdAt DESC)`.
|
|
24
|
+
*
|
|
25
|
+
* The pre-existing `(ownerType, ownerId, createdAt DESC)` index does NOT serve
|
|
26
|
+
* this query: Firestore matches an index by its field sequence, and `ownerId`
|
|
27
|
+
* sits between the equality and the sort, so a query that filters on
|
|
28
|
+
* `ownerType` alone and orders by `createdAt` has no usable prefix. Without the
|
|
29
|
+
* two-field index this throws FAILED_PRECONDITION, which `mapToHttpError`
|
|
30
|
+
* renders as a **409** — and `/admin/address-clusters` then showed "Failed to
|
|
31
|
+
* load clusters." *and* "No shared addresses found." at the same time, so the
|
|
32
|
+
* page read as "there are no clusters" rather than as an error. Added
|
|
33
|
+
* 2026-10-02; see Recurrent Root Cause #2.
|
|
34
|
+
*/
|
|
22
35
|
listByOwnerType(ownerType: AddressOwnerType, limit?: number): Promise<AddressDocument[]>;
|
|
23
36
|
countByOwner(ownerType: AddressOwnerType, ownerId: string): Promise<number>;
|
|
24
37
|
createForOwner(ownerType: AddressOwnerType, ownerId: string, input: AddressCreateInput): Promise<AddressDocument>;
|
|
@@ -60,6 +60,19 @@ export class AddressesRepository extends BaseRepository {
|
|
|
60
60
|
throw new DatabaseError(`Failed to list addresses for ${ownerType}:${ownerId}`, error);
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* 🛑 Requires the composite index `(ownerType ASC, createdAt DESC)`.
|
|
65
|
+
*
|
|
66
|
+
* The pre-existing `(ownerType, ownerId, createdAt DESC)` index does NOT serve
|
|
67
|
+
* this query: Firestore matches an index by its field sequence, and `ownerId`
|
|
68
|
+
* sits between the equality and the sort, so a query that filters on
|
|
69
|
+
* `ownerType` alone and orders by `createdAt` has no usable prefix. Without the
|
|
70
|
+
* two-field index this throws FAILED_PRECONDITION, which `mapToHttpError`
|
|
71
|
+
* renders as a **409** — and `/admin/address-clusters` then showed "Failed to
|
|
72
|
+
* load clusters." *and* "No shared addresses found." at the same time, so the
|
|
73
|
+
* page read as "there are no clusters" rather than as an error. Added
|
|
74
|
+
* 2026-10-02; see Recurrent Root Cause #2.
|
|
75
|
+
*/
|
|
63
76
|
async listByOwnerType(ownerType, limit = 500) {
|
|
64
77
|
try {
|
|
65
78
|
const snapshot = await this.getCollection()
|
|
@@ -91,8 +91,27 @@ export function AdminBundleEditorView({ bundleId, onSaved, onDeleted, scope = "a
|
|
|
91
91
|
const brandsQuery = useQuery({
|
|
92
92
|
queryKey: ["admin", "brands", "picker"],
|
|
93
93
|
queryFn: async () => {
|
|
94
|
+
/*
|
|
95
|
+
* 🛑 `res.items`, NOT `res.data.items` — `apiClient` ALREADY unwraps the
|
|
96
|
+
* envelope (`ApiClient.ts` ends `return data.data as T`), so what arrives
|
|
97
|
+
* here is `{ items, total, page, pageSize, totalPages, hasMore }` and
|
|
98
|
+
* there is no second `.data` to reach through.
|
|
99
|
+
*
|
|
100
|
+
* This read was `res?.data?.items ?? []` behind a hand-written
|
|
101
|
+
* `as { data?: { items?: CategoryDocument[] } }`. The cast cannot be wrong
|
|
102
|
+
* at runtime — it only silenced the one check that would have caught it —
|
|
103
|
+
* and `?? []` turned the miss into an empty list rather than an error. So
|
|
104
|
+
* the Brand select offered ONLY "No specific brand", for every admin,
|
|
105
|
+
* forever: verified in production 2026-10-02 by polling the live
|
|
106
|
+
* `<select name="brandSlug">` for 15s (optionCount stayed 1) while
|
|
107
|
+
* `GET /api/admin/brands` returned 200 with 5 brand rows in `data.items`.
|
|
108
|
+
*
|
|
109
|
+
* Nothing was wrong with the endpoint, the query, the memo or its
|
|
110
|
+
* dependency array — I checked each before concluding. Root Cause #98's
|
|
111
|
+
* family: a cast at a boundary asserting a shape that is not there.
|
|
112
|
+
*/
|
|
94
113
|
const res = (await apiClient.get(ADMIN_ENDPOINTS.BRANDS));
|
|
95
|
-
return res?.
|
|
114
|
+
return res?.items ?? [];
|
|
96
115
|
},
|
|
97
116
|
});
|
|
98
117
|
const brandOptions = React.useMemo(() => [
|
|
@@ -105,8 +124,28 @@ export function AdminBundleEditorView({ bundleId, onSaved, onDeleted, scope = "a
|
|
|
105
124
|
const bundleQuery = useQuery({
|
|
106
125
|
queryKey: ["bundle", scope, bundleId],
|
|
107
126
|
queryFn: async () => {
|
|
127
|
+
/*
|
|
128
|
+
* 🛑 The response IS the bundle — do not reach through a second `.data`.
|
|
129
|
+
*
|
|
130
|
+
* Same defect as `brandsQuery` above and far more dangerous. This was
|
|
131
|
+
* `res?.data ?? null` behind `as { data?: CategoryDocument }`, so every
|
|
132
|
+
* edit of an existing bundle resolved to `null`, `bundleToForm(null)`
|
|
133
|
+
* returned `EMPTY_FORM`, and the editor rendered BLANK — then saving it
|
|
134
|
+
* would have written that blank over a live bundle.
|
|
135
|
+
*
|
|
136
|
+
* Verified in production 2026-10-02: /admin/bundles/
|
|
137
|
+
* bundle-every-generation-starter-pack/edit polled for 14s with
|
|
138
|
+
* `input[name="name"]` and `input[name="priceRupees"]` both `""` and no
|
|
139
|
+
* member count, while `GET /api/admin/bundles/{id}` returned 200 with the
|
|
140
|
+
* real bundle (`{ success, data: <bundle> }`, no nested `data`). I did not
|
|
141
|
+
* press Save.
|
|
142
|
+
*
|
|
143
|
+
* Root Cause #98: an `ActionResult`/envelope spread as if it were the
|
|
144
|
+
* payload, where the destructive half is the status field silently
|
|
145
|
+
* defaulting on the way back out.
|
|
146
|
+
*/
|
|
108
147
|
const res = (await apiClient.get(endpoints.byId(encodeURIComponent(bundleId))));
|
|
109
|
-
return res
|
|
148
|
+
return res ?? null;
|
|
110
149
|
},
|
|
111
150
|
enabled: isEdit,
|
|
112
151
|
});
|
|
@@ -10,7 +10,7 @@ export declare const BUNDLE_RULE_TYPE_OPTIONS: {
|
|
|
10
10
|
*/
|
|
11
11
|
export declare const bundleFormSchema: z.ZodEffects<z.ZodObject<{
|
|
12
12
|
name: z.ZodString;
|
|
13
|
-
priceRupees: z.ZodEffects<z.
|
|
13
|
+
priceRupees: z.ZodEffects<z.ZodNumber, number, number>;
|
|
14
14
|
description: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
|
|
15
15
|
ruleType: z.ZodEnum<["static", "dynamic"]>;
|
|
16
16
|
productIds: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
@@ -61,7 +61,7 @@ export declare const bundleFormSchema: z.ZodEffects<z.ZodObject<{
|
|
|
61
61
|
}, "strip", z.ZodTypeAny, {
|
|
62
62
|
name: string;
|
|
63
63
|
isActive: boolean;
|
|
64
|
-
priceRupees:
|
|
64
|
+
priceRupees: number;
|
|
65
65
|
ruleType: "static" | "dynamic";
|
|
66
66
|
brandSlug?: string | undefined;
|
|
67
67
|
description?: string | undefined;
|
|
@@ -81,7 +81,7 @@ export declare const bundleFormSchema: z.ZodEffects<z.ZodObject<{
|
|
|
81
81
|
}, {
|
|
82
82
|
name: string;
|
|
83
83
|
isActive: boolean;
|
|
84
|
-
priceRupees:
|
|
84
|
+
priceRupees: number;
|
|
85
85
|
ruleType: "static" | "dynamic";
|
|
86
86
|
brandSlug?: string | undefined;
|
|
87
87
|
description?: string | undefined;
|
|
@@ -101,7 +101,7 @@ export declare const bundleFormSchema: z.ZodEffects<z.ZodObject<{
|
|
|
101
101
|
}>, {
|
|
102
102
|
name: string;
|
|
103
103
|
isActive: boolean;
|
|
104
|
-
priceRupees:
|
|
104
|
+
priceRupees: number;
|
|
105
105
|
ruleType: "static" | "dynamic";
|
|
106
106
|
brandSlug?: string | undefined;
|
|
107
107
|
description?: string | undefined;
|
|
@@ -121,7 +121,7 @@ export declare const bundleFormSchema: z.ZodEffects<z.ZodObject<{
|
|
|
121
121
|
}, {
|
|
122
122
|
name: string;
|
|
123
123
|
isActive: boolean;
|
|
124
|
-
priceRupees:
|
|
124
|
+
priceRupees: number;
|
|
125
125
|
ruleType: "static" | "dynamic";
|
|
126
126
|
brandSlug?: string | undefined;
|
|
127
127
|
description?: string | undefined;
|
|
@@ -73,10 +73,39 @@ export const bundleFormSchema = z
|
|
|
73
73
|
section: "basics", sectionLabel: "Bundle", sectionRequired: true,
|
|
74
74
|
quick: true, order: 1, row: "pair", label: "Name",
|
|
75
75
|
}),
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
/*
|
|
77
|
+
* 🛑 `z.coerce.number()`, NOT `z.string()`, because `kind: "number"` below
|
|
78
|
+
* decides the runtime type and the schema does not get a vote.
|
|
79
|
+
*
|
|
80
|
+
* `build-sections.tsx`'s number control is
|
|
81
|
+
* `onChange={(v) => set(v === "" ? undefined : Number(v))}` — it writes a
|
|
82
|
+
* NUMBER into the draft on every keystroke. This field used to be
|
|
83
|
+
* `z.string().regex(...)`, so a save with 1500 visibly in the box was
|
|
84
|
+
* refused with "This field is required" sitting directly under it, and the
|
|
85
|
+
* summary read "Bundle: This field is required" — naming the section and no
|
|
86
|
+
* field. A bundle therefore could not be created at all. Verified in
|
|
87
|
+
* production 2026-10-02 by walking up from the role=alert element to its
|
|
88
|
+
* owner, `input[name="priceRupees"]`.
|
|
89
|
+
*
|
|
90
|
+
* Do NOT "fix" this by removing the coercion in `build-sections.tsx`:
|
|
91
|
+
* ~10 of the 30 `kind: "number"` fields are plain `z.number()` and depend on
|
|
92
|
+
* it. Two other fields still declare `z.string()` under the same control and
|
|
93
|
+
* have the same latent defect — `purchasedItemNumber` (admin-user-form) and
|
|
94
|
+
* `maxBudget` (item-request-create-form); neither is driven by a test yet,
|
|
95
|
+
* so neither was changed blind.
|
|
96
|
+
*
|
|
97
|
+
* `coerce` keeps every existing caller working: `EMPTY_FORM.priceRupees` is
|
|
98
|
+
* `""` (→ the control sends `undefined` → "This field is required", which is
|
|
99
|
+
* the right message for an empty price), `bundleToForm` supplies a string,
|
|
100
|
+
* and the submit still does `Number(form.priceRupees)`.
|
|
101
|
+
*
|
|
102
|
+
* The two-decimal rule is kept as a refine over `String(n)` rather than
|
|
103
|
+
* `multipleOf(0.01)`, which is not float-safe.
|
|
104
|
+
*/
|
|
105
|
+
priceRupees: annotate(z.coerce
|
|
106
|
+
.number({ message: BUNDLE_COPY.adminEditor.errors.priceInvalid })
|
|
107
|
+
.positive(BUNDLE_COPY.adminEditor.errors.priceInvalid)
|
|
108
|
+
.refine((n) => Number.isFinite(n) && /^\d+(\.\d{1,2})?$/.test(String(n)), BUNDLE_COPY.adminEditor.errors.priceInvalid), {
|
|
80
109
|
section: "basics", quick: true, order: 2, row: "pair",
|
|
81
110
|
kind: "number", label: "Bundle price (₹)",
|
|
82
111
|
help: "What the buyer pays for the whole bundle — normally less than the members' total.",
|
|
@@ -4,6 +4,7 @@ import { useRef, useState, useEffect, useCallback } from "react";
|
|
|
4
4
|
import Link from "next/link";
|
|
5
5
|
import { Div, Li, Nav, Row, Span, Ul } from "../../ui";
|
|
6
6
|
import { NavbarChevron } from "../../ui/components/NavbarChevron";
|
|
7
|
+
import { findActiveNavItem } from "../../_internal/client/features/layout/navActive";
|
|
7
8
|
const __O = {
|
|
8
9
|
hidden: "overflow-hidden",
|
|
9
10
|
};
|
|
@@ -48,8 +49,28 @@ export function NavbarLayout({ items, activeHref, id = "main-navbar", ariaLabel
|
|
|
48
49
|
};
|
|
49
50
|
}, [updateArrows, items]);
|
|
50
51
|
const scroll = (dir) => scrollRef.current?.scrollBy({ left: dir === "left" ? -240 : 240, behavior: "smooth" });
|
|
52
|
+
// 🛑 Resolve the active item with `findActiveNavItem`, NOT `activeHref === item.href`.
|
|
53
|
+
//
|
|
54
|
+
// Strict equality is what this used, and it meant the top navbar marked
|
|
55
|
+
// nothing at all on any detail page: /products marked "Products", but
|
|
56
|
+
// /products/product-beyblade-original-dranzer-s marked nothing — verified in
|
|
57
|
+
// production 2026-10-02, where the Products and Home links had byte-identical
|
|
58
|
+
// computed styles (colour rgb(91,91,99), weight 500, transparent background,
|
|
59
|
+
// 0px bottom border), so there was no visual marker either. A visitor who
|
|
60
|
+
// drills from a listing into an item loses their place in the navigation.
|
|
61
|
+
//
|
|
62
|
+
// The sidebars and the mobile drawer were always correct, because they
|
|
63
|
+
// already called this helper — which is why the mobile bottom nav highlighted
|
|
64
|
+
// "Auctions" on /auctions while the desktop navbar above it highlighted
|
|
65
|
+
// nothing. One algorithm, four surfaces.
|
|
66
|
+
//
|
|
67
|
+
// `findActiveNavItem` prefix-matches (`activeHref.startsWith(`${href}/`)`) and
|
|
68
|
+
// resolves ties by LONGEST href, so a root entry like "/" cannot light up on
|
|
69
|
+
// every page and a more specific entry always beats a shorter one.
|
|
70
|
+
const activeItem = findActiveNavItem(items, activeHref);
|
|
71
|
+
const isItemActive = (item) => activeItem?.href === item.href;
|
|
51
72
|
if (inline) {
|
|
52
|
-
return (_jsx(Ul, { "aria-label": ariaLabel, className: "hidden xl:flex items-center justify-center gap-[var(--appkit-space-0-5)] xl:gap-[var(--appkit-space-1)] w-full", children: items.map((item) => (_jsx(Li, { children: renderItem ? (renderItem(item,
|
|
73
|
+
return (_jsx(Ul, { "aria-label": ariaLabel, className: "hidden xl:flex items-center justify-center gap-[var(--appkit-space-0-5)] xl:gap-[var(--appkit-space-1)] w-full", children: items.map((item) => (_jsx(Li, { children: renderItem ? (renderItem(item, isItemActive(item))) : (_jsx(DefaultNavItem, { item: item, isActive: isItemActive(item) })) }, item.href))) }));
|
|
53
74
|
}
|
|
54
|
-
return (_jsx(Nav, { border: "subtle", id: id, "aria-label": ariaLabel, className: "hidden lg:block bg-[var(--appkit-color-bg)]/95 backdrop-blur-md border-b", children: _jsxs(Row, { className: "container mx-auto sm:px-[var(--appkit-space-6)] lg:px-[var(--appkit-space-8)] max-w-[1920px] h-10", padding: "x-md", align: "center", reverse: "hand", children: [_jsxs(Div, { className: `relative flex-1 ${__O.hidden}`, children: [canScrollLeft && (_jsx(NavbarChevron, { direction: "left", onClick: () => scroll("left") })), _jsx(Ul, { ref: scrollRef, className: "flex items-center justify-center gap-[var(--appkit-space-0-5)] lg:gap-[var(--appkit-space-1)] overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]", children: items.map((item) => (_jsx(Li, { className: "shrink-0", children: renderItem ? (renderItem(item,
|
|
75
|
+
return (_jsx(Nav, { border: "subtle", id: id, "aria-label": ariaLabel, className: "hidden lg:block bg-[var(--appkit-color-bg)]/95 backdrop-blur-md border-b", children: _jsxs(Row, { className: "container mx-auto sm:px-[var(--appkit-space-6)] lg:px-[var(--appkit-space-8)] max-w-[1920px] h-10", padding: "x-md", align: "center", reverse: "hand", children: [_jsxs(Div, { className: `relative flex-1 ${__O.hidden}`, children: [canScrollLeft && (_jsx(NavbarChevron, { direction: "left", onClick: () => scroll("left") })), _jsx(Ul, { ref: scrollRef, className: "flex items-center justify-center gap-[var(--appkit-space-0-5)] lg:gap-[var(--appkit-space-1)] overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]", children: items.map((item) => (_jsx(Li, { className: "shrink-0", children: renderItem ? (renderItem(item, isItemActive(item))) : (_jsx(DefaultNavItem, { item: item, isActive: isItemActive(item) })) }, item.href))) }), canScrollRight && (_jsx(NavbarChevron, { direction: "right", onClick: () => scroll("right") }))] }), rightSlot && _jsx(Div, { className: "shrink-0", children: rightSlot })] }) }));
|
|
55
76
|
}
|
|
@@ -1,63 +1,67 @@
|
|
|
1
|
-
{
|
|
2
|
-
"mediaEditor": {
|
|
3
|
-
"cropTitle": "Crop Image",
|
|
4
|
-
"cropInstruction": "Drag to reposition. Use the zoom slider or +/- keys to zoom.",
|
|
5
|
-
"cropPreviewAlt": "Preview",
|
|
6
|
-
"cropZoom": "Zoom",
|
|
7
|
-
"cropZoomIn": "Zoom in",
|
|
8
|
-
"cropZoomOut": "Zoom out",
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
|
|
63
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"mediaEditor": {
|
|
3
|
+
"cropTitle": "Crop Image",
|
|
4
|
+
"cropInstruction": "Drag to reposition. Use the zoom slider or +/- keys to zoom.",
|
|
5
|
+
"cropPreviewAlt": "Preview",
|
|
6
|
+
"cropZoom": "Zoom",
|
|
7
|
+
"cropZoomIn": "Zoom in",
|
|
8
|
+
"cropZoomOut": "Zoom out",
|
|
9
|
+
"cropRotateLeft": "Rotate left 90°",
|
|
10
|
+
"cropRotateRight": "Rotate right 90°",
|
|
11
|
+
"cropFlipH": "Flip horizontally",
|
|
12
|
+
"cropFlipV": "Flip vertically",
|
|
13
|
+
"cropWarningTitle": "Low zoom",
|
|
14
|
+
"cropWarningMessage": "Zoom in for better quality.",
|
|
15
|
+
"cropPosition": "Position",
|
|
16
|
+
"cropReset": "Reset",
|
|
17
|
+
"cropSave": "Save Crop",
|
|
18
|
+
"cropCancel": "Cancel",
|
|
19
|
+
"trimTitle": "Trim Video",
|
|
20
|
+
"trimInstruction": "Set the start and end times, then click Trim.",
|
|
21
|
+
"trimStart": "Start",
|
|
22
|
+
"trimEnd": "End",
|
|
23
|
+
"trimDuration": "Duration: {duration}s",
|
|
24
|
+
"trimQuality": "Quality",
|
|
25
|
+
"trimQualityLow": "Low",
|
|
26
|
+
"trimQualityMedium": "Medium",
|
|
27
|
+
"trimQualityHigh": "High",
|
|
28
|
+
"trimSave": "Trim Video",
|
|
29
|
+
"trimSkip": "Skip",
|
|
30
|
+
"trimming": "Trimming…",
|
|
31
|
+
"thumbnailTitle": "Select Thumbnail",
|
|
32
|
+
"thumbnailInstruction": "Seek to the frame you want to use as the thumbnail.",
|
|
33
|
+
"thumbnailCapture": "Use This Frame",
|
|
34
|
+
"thumbnailSkip": "Skip",
|
|
35
|
+
"thumbnailCapturing": "Capturing…",
|
|
36
|
+
"thumbnailUploading": "Uploading…",
|
|
37
|
+
"cropSuccess": "Image cropped",
|
|
38
|
+
"trimSuccess": "Video trimmed",
|
|
39
|
+
"thumbnailSuccess": "Thumbnail saved",
|
|
40
|
+
"cropError": "Failed to crop image",
|
|
41
|
+
"trimError": "Failed to trim video",
|
|
42
|
+
"thumbnailError": "Failed to capture thumbnail",
|
|
43
|
+
"editImage": "Edit / Crop",
|
|
44
|
+
"editVideo": "Trim / Thumbnail"
|
|
45
|
+
},
|
|
46
|
+
"camera": {
|
|
47
|
+
"takePhoto": "Take Photo",
|
|
48
|
+
"startRecording": "Start Recording",
|
|
49
|
+
"stopRecording": "Stop Recording",
|
|
50
|
+
"flipCamera": "Flip Camera",
|
|
51
|
+
"switchToCamera": "Use Camera",
|
|
52
|
+
"switchToUpload": "Upload File",
|
|
53
|
+
"cameraError": "Camera unavailable",
|
|
54
|
+
"permissionDenied": "Camera permission denied. Please allow access in your browser settings.",
|
|
55
|
+
"notSupported": "Camera is not supported on this device",
|
|
56
|
+
"starting": "Starting camera..."
|
|
57
|
+
},
|
|
58
|
+
"upload": {
|
|
59
|
+
"change": "Change",
|
|
60
|
+
"remove": "Remove",
|
|
61
|
+
"clickToUpload": "Click to upload",
|
|
62
|
+
"uploading": "Uploading…",
|
|
63
|
+
"uploadingProgress": "Uploading {progress}%…",
|
|
64
|
+
"replaceFile": "Replace file",
|
|
65
|
+
"chooseFile": "Choose file to upload"
|
|
66
|
+
}
|
|
67
|
+
}
|